Skip to main content

Custom Error Types

Using String or &str as errors is:

  • Hard to match on
  • Easy to misuse
  • Not expressive

Custom error types let you:

  • Represent different failure cases clearly
  • Attach data to errors
  • Use ? cleanly with multiple error sources
  • Provide better debugging and user messages

Basic Custom Error Type with enum

Example 1: Simple calculator errors

use std::fmt;

#[derive(Debug)]
enum CalcError {
DivisionByZero,
NegativeNumber,
}

impl fmt::Display for CalcError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CalcError::DivisionByZero => write!(f, "Cannot divide by zero"),
CalcError::NegativeNumber => write!(f, "Negative numbers are not allowed"),
}
}
}

impl std::error::Error for CalcError {}

fn divide(a: i32, b: i32) -> Result<i32, CalcError> {
if b == 0 {
Err(CalcError::DivisionByZero)
} else if a < 0 || b < 0 {
Err(CalcError::NegativeNumber)
} else {
Ok(a / b)
}
}

fn main() {
match divide(10, 0) {
Ok(result) => println!("Result: {}", result),
Err(e) => println!("Error: {}", e),
}
}
  • enum CalcError defines all possible error cases.
  • Implements:
    • Debug → for debugging output.
    • Display → for user-friendly messages.
    • Error → so it works with standard error handling tools.
  • The function returns Result<i32, CalcError>.

Adding underlying errors (wrapping errors)

Often, your function calls other functions that already return errors (like io::Error or ParseIntError). You want to wrap those instead of losing information.

Example 2: Wrapping I/O and parsing errors

use std::fs;
use std::io;
use std::num::ParseIntError;
use std::fmt;

#[derive(Debug)]
enum ReadNumberError {
Io(io::Error),
Parse(ParseIntError),
}

impl fmt::Display for ReadNumberError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ReadNumberError::Io(e) => write!(f, "I/O error: {}", e),
ReadNumberError::Parse(e) => write!(f, "Parse error: {}", e),
}
}
}

impl std::error::Error for ReadNumberError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ReadNumberError::Io(e) => Some(e),
ReadNumberError::Parse(e) => Some(e),
}
}
}

// Enable automatic conversion using `?`
impl From<io::Error> for ReadNumberError {
fn from(err: io::Error) -> Self {
ReadNumberError::Io(err)
}
}

impl From<ParseIntError> for ReadNumberError {
fn from(err: ParseIntError) -> Self {
ReadNumberError::Parse(err)
}
}

fn read_number(path: &str) -> Result<i32, ReadNumberError> {
let contents = fs::read_to_string(path)?; // io::Error → ReadNumberError
let number = contents.trim().parse::<i32>()?; // ParseIntError → ReadNumberError
Ok(number)
}

fn main() {
match read_number("number.txt") {
Ok(n) => println!("Number: {}", n),
Err(e) => {
println!("Error: {}", e);
if let Some(source) = e.source() {
println!("Caused by: {}", source);
}
}
}
}
  • The enum stores underlying errors.
  • From implementations allow ? to work seamlessly.
  • source() enables error chaining (useful for logging).

Manually implementing Display, Error, and From can get verbose. The thiserror crate automates this.

Example 3: Same error using thiserror

use thiserror::Error;
use std::io;
use std::num::ParseIntError;

#[derive(Debug, Error)]
enum ReadNumberError {
#[error("I/O error: {0}")]
Io(#[from] io::Error),

#[error("Failed to parse number: {0}")]
Parse(#[from] ParseIntError),
}

fn read_number(path: &str) -> Result<i32, ReadNumberError> {
let contents = std::fs::read_to_string(path)?;
let number = contents.trim().parse::<i32>()?;
Ok(number)
}
  • #[derive(Error)] generates:
    • Display
    • Error
    • From automatically.
  • Much cleaner and more maintainable.

Designing good custom errors

Good error types:

  • Are specific (not just UnknownError)
  • Use enums for multiple cases
  • Wrap underlying errors instead of discarding them
  • Provide helpful Display messages

Library vs Application Errors

Libraries:

  • Use precise custom error enums
  • Let callers match on variants

Applications:

  • Often use Box<dyn Error> or anyhow::Error at top-level
  • Internally still benefit from structured errors

Summary

  • Custom error types improve clarity, safety, and debugging.
  • Use enum to represent failure cases.
  • Implement Display, Error, and From for usability.
  • Use thiserror to reduce boilerplate.
  • Combine with ? for clean, composable error propagation.